第8章 对象、类与面向对象编程(2)

继承

实现继承是 JS 唯一支持的继承方式,而这主要是通过原型链实现的,其基本思想就是通过原型继承多个引用类型的属性和方法。

重温一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链,以下代码展示一个基本的原型链:

// 定义 SuperType 类型,并分别定义一个属性和一个方法
function SuperType() {
  this.property = true;
}

SuperType.prototype.getSuperValue = function() {
  return this.property;
};

// 定义 SubType 类型,并分别定义一个属性和一个方法
function SubType() {
  this.subproperty = false;
}

SubType.prototype.getSubValue = function () {
  return this.subproperty;
}

// 通过设置 prototype 使 SubType 继承自 SuperType
SubType.prototype = new SuperType();

let instance = new SubType();
// 在 SubType 实例上调用 SuperType 的方法
// 促使实例通过原型链找到指定方法
console.log(instance.getSuperValue()); // true
// 所有引用类型都继承自 Object
console.log(instance.toString()); // [object Object]

上述例子中实现继承的关键,是 SubType 没有使用默认原型,而是将其替换成了一个新的对象,即 SuperType 的实例。这样一来,SubType 的实例不仅能从 SuperType 的实例中继承属性和方法,而且还与 SuperType 的原型挂上了钩。于是 instance 通过内部的 [[Prototype]] 指向 SubType.prototype,而 SubType.prototype 又通过内部的 [[Prototype]] 指向 SuperType.prototype。

原型与实例的关系可以通过两种方式来确定。第一种方式是使用 instanceof 操作符,第二种方式是使用 isPrototypeOf()方法。

console.log(instance instanceof Object);     // true
console.log(instance instanceof SuperType);  // true
console.log(instance instanceof SubType);    // true

console.log(Object.prototype.isPrototypeOf(instance)); // true
console.log(SuperType.prototype.isPrototypeOf(instance)); // true
console.log(SubType.prototype.isPrototypeOf(instance)); // true

原型链的问题

原型链虽然是实现继承的强大工具,但它也有问题:

  1. 原型中包含的引用值会在所有实例间共享

    function SuperType() {
      this.colors = ["red", "blue", "green"];
    }
    
    function SubType() {}
    // 继承 SuperType
    SubType.prototype = new SuperType();
    
    let instance1 = new SubType();
    instance1.colors.push("black"); console.log(instance1.colors); // "red,blue,green,black"
    let instance2 = new SubType(); console.log(instance2.colors); // "red,blue,green,black"
  2. 子类型无法在不 影响所有对象实例的情况下把参数传进父类的构造函数

为了解决以上两个问题,可以使用“盗用构造函数”的技术来实现。基本思路如下:

function SuperType() {
  this.colors = ["red", "blue", "green"];
}

function SubType() {
  // 子类构造函数调用 appley() 或者 call() 方法,继承 SuperType
    SuperType.call(this);
}

let instance1 = new SubType(); 
instance1.colors.push("black"); 
console.log(instance1.colors); // "red,blue,green,black"
let instance2 = new SubType();
console.log(instance2.colors); // "red,blue,green"

还可以通过该方法向父类构造函数传参

function SuperType(name){
  this.name = name;
}

function SubType(name) {
  SuperType.call(this, name);
}

let instance1 = new SubType("Nicholas");
console.log(instance1.name); // "Nicholas";

let instance2 = new SubType("Leo");
console.log(instance2.name); // "Leo";

“盗用构造函数”这种方式也有缺点,它必须在构造函数中定义方法,因此函数不能重用。另外,子类也不能访问父类原型上定义的方法,因此盗用构造函数基本不能单独使用。

组合继承综合了原型链和盗用构造函数,将两者的优点集中了起来。其思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。

function SuperType(name){
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() {
  console.log(this.name);
};

function SubType(name, age){ 
  // 继承属性 
  SuperType.call(this, name);
  this.age = age;
}

// 继承方法
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function() {
  console.log(this.age);
};

let instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"
instance1.sayName(); // "Nicholas";
instance1.sayAge(); // 29

let instance2 = new SubType("Greg", 27);
console.log(instance2.colors);  // "red,blue,green"
instance2.sayName();            // "Greg";
instance2.sayAge();             // 27

组合继承弥补了原型链和盗用构造函数的不足,是 JS 中使用最多的继承模式。

JS 可以通过 Object.create() 方法实现原型式继承。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个参数可选)。

let person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = Object.create(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");

let yetAnotherPerson = Object.create(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");

console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"

Object.create() 的第二个参数与 Object.defineProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。

let person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};

let anotherPerson = Object.create(person, {
  name: {
    value: "Greg"
  }
});
console.log(anotherPerson.name);  // "Greg"

原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

在组合继承的过程中,父类构造函数会被调用两次,因此会存在效率问题。

寄生式组合继承可以解决这个问题。寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。基本模式如下:

function inheritPrototype(subType, superType) {
  let prototype = object(superType.prototype); // 创建对象 
  prototype.constructor = subType; // 增强对象 
  subType.prototype = prototype; // 赋值对象
}

inheritPrototype 函数接收两个参数:子类构造函数和父类构造函数。在这个函数内部,第一步是创建父类原型的一个副本。然后,给返回的 prototype 对象设置 constructor 属性,解决由于重写原型导致默认 constructor 丢失的问题。最后将新创建的对象赋值给子类型的原型。完整示例如下:

// 定义父类
function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() {
  console.log(this.name);
};

// 定义子类
function SubType(name, age) {
  // 继承父类
  SuperType.call(this, name);
  this.age = age;
}

function inheritPrototype(subType, superType) {
  let prototype = object(superType.prototype); // 创建对象 
  prototype.constructor = subType; // 增强对象 
  subType.prototype = prototype; // 赋值对象
}
inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function() {
  console.log(this.age);
};

这里只调用了一次 SuperType 构造函数,避免了 SubType.prototype 上不必要也用不到的属性,因此寄生式组合继承可以算是引用类型继承的最佳模式。

定义类可以通过两种主要方式:使用 class 关键字进行类声明和类表达式

// 类声明
class Person {}

// 类表达式
const Animal = class {};

类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法。

// 空类定义,有效
class Foo {}
// 有构造函数的类,有效 
class Bar {
  constructor() {}
}
// 有获取函数的类,有效 
class Baz {
  get myBaz() {}
}
// 有静态方法的类,有效 
class Qux {
  static myQux() {}
}

constructor 关键字用于在类定义块内部创建类的构造函数,省略构造函数相当于将构造函数定义为空函数。

使用 new 操作符实例化 Person 的操作等于使用 new 调用其构造函数。

class Animal {}

class Person {
  constructor() {
    console.log('person ctor');
  }
}

class Vegetable {
  constructor() {
    this.color = 'orange';
  }
}

let a = new Animal();
let p = new Person();
let v = new Vegetable();
console.log(v.color);

类实例化时传入的参数会用作构造函数的参数,不传参数时可以省略类名后的括号

class Person {
  constructor(name) {
    console.log(arguments.length);
    this.name = name || null;
  }
}
let p1 = new Person;  // 0
console.log(p1.name); // null

let p2 = new Person(); // 0
console.log(p2.name);  // null

let p3 = new Person('Jake');  // 1
console.log(p3.name);         // Jake

类构造函数执行后会返回 this 对象,不过也可以自定义返回的对象,但是这个自定义的对象不会通过 instanceof 操作符检测出与类关联

class Person {
  constructor(override) {
    this.foo = 'foo';
    if (override) {
      return {
        bar: 'bar'
      }; 
    }
  } 
}
let p1 = new Person(), 
    p2 = new Person(true);
console.log(p1);                    // Person { foo: 'foo' }
console.log(p1 instanceof Person);  // true

console.log(p2);                    // { bar: 'bar' }
console.log(p2 instanceof Person);  // false

类构造函数与构造函数的主要区别是,调用类构造函数必须使用 new 操作符,而普通构造函数如果不使用 new 调用,那么就会以全局的 this (通常是 window) 作为内部对象。

类本身具有与普通构造函数一样的行为,在类的上下文中,类本身在使用 new 调用时会被当成构造函数。重点在于,类中定义的 constructor 方法不会被当成构造函数,在对它使用 instanceof 操作符时会返回 false。但是,如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么 instanceof 操作符的返回值会返回true。

class Person {}
let p1 = new Person();
console.log(p1.constructor === Person); // true
console.log(p1 instanceof Person); // true
console.log(p1 instanceof Person.constructor); // false

let p2 = new Person.constructor();
console.log(p2.constructor === Person); // false
console.log(p2 instanceof Person); // false
console.log(p2 instanceof Person.constructor);  // true

每次通过 new 调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例添加“自有”属性。在构造函数执行完毕后,仍然可以给实例继续添加新成员。每个实例都对应一个唯一的成员对象,所有成员都不会在原型上共享:

class Person {
  constructor() {
    this.name = new String('Jack');
    this.sayName = () => console.log(this.name);
    this.nicknames = ['Jake', 'J-Dog']
  }
}

let p1 = new Person(),
    p2 = new Person();
p1.sayName(); // Jack
p2.sayName(); // Jack
console.log(p1.name === p2.name); // false
console.log(p1.sayName === p2.sayName); // false
console.log(p1.nicknames === p2.nicknames); // false
p1.name = p1.nicknames[0];
p2.name = p2.nicknames[1];
p1.sayName();  // Jake
p2.sayName();  // J-Dog

为了在实例间共享方法,类块中定义的方法会定义到原型上:

class Person {
  constructor() {
    // this 上的内容会存在于不同的实例上
    this.locate = () => console.log('instance');
  }
  // 类块中定义的内容会出现在原型上
  locate() {
    console.log('prototype');
  }
}
let p = new Person();
p.locate();                 // instance
Person.prototype.locate();  // prototype

类定义也支持获取和设置访问器。语法与行为跟普通对象一样:

class Person {
  set name(newName) {
    this.name_ = newName;
  }

  get name() {
    return this.name_;
  }
}

可以在类上定义静态方法用于执行不特定于实例的操作,使用 static 关键字作为前缀:

class Person {
  constructor() {
    // 添加到 this 的所有内容都会存在于不同的实例上
    this.locate = () => console.log('instance', this);
  }
  // 定义在类的原型对象上 
  locate() {
    console.log('prototype', this);
  }
  // 定义在类本身上 
  static locate() {
    console.log('class', this);
  }
}
let p = new Person();
p.locate();                 // instance, Person {}
Person.prototype.locate();  // prototype, {constructor: ... }
Person.locate();            // class, class Person {}

类定义语法支持在原型和类本身上定义生成器方法:

class Person {
  // 在原型上定义生成器方法
  *createNicknameIterator() {
    yield 'Jack';
    yield 'Jake';
    yield 'J-Dog';
  }

  // 在类上定义生成器方法
  static *createJobIterator() {
    yield 'Butcher';
    yield 'Baker';
    yield 'Candlestick maker';
  }
}
let jobIter = Person.createJobIterator();
console.log(jobIter.next().value);  // Butcher
console.log(jobIter.next().value);  // Baker
console.log(jobIter.next().value);  // Candlestick maker

let p = new Person();
let nicknameIter = p.createNicknameIterator();
console.log(nicknameIter.next().value);  // Jack
console.log(nicknameIter.next().value);  // Jake
console.log(nicknameIter.next().value);  // J-Dog

可以通过添加一个默认的迭代器,把类实例变成可迭代对象:

class Person {
  constructor() {
    this.nicknames = ['Jack', 'Jake', 'J-Dog'];
  }
  *[Symbol.iterator]() {
    yield *this.nicknames.entries();
  }
}

let p = new Person();
for (let [idx, nickname] of p) {
  console.log(nickname);
}
// Jack
// Jake
// J-Dog

ES6 类支持单继承。使用 extends 关键字,就可以继承任何拥有 [[Construct]] 和原型的对象。虽然类继承使用的是新语法,但本质上依旧使用的是原型链。

class Vehicle {}
// 继承类
class Bus extends Vehicle {}
let b = new Bus();
console.log(b instanceof Bus);      // true
console.log(b instanceof Vehicle);  // true

function Person() {}
// 继承普通构造函数
class Engineer extends Person {}
let e = new Engineer();
console.log(e instanceof Engineer);  // true
console.log(e instanceof Person);    // true

派生类的方法可以通过 super 关键字引用它们的原型。这个关键字只能在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部。

class Vehicle {
  constructor() {
    this.hasEngine = true;
  }
}
class Bus extends Vehicle {
  constructor() {
    // 不要在调用super()之前引用this,否则会抛出ReferenceError 
    super(); // 相当于super.constructor()
    console.log(this instanceof Vehicle); // true
    console.log(this); // Bus { hasEngine: true }
  }
}

使用 super 时要注意几个问题

通过 new.target 可以创建一个抽象类,以阻止该类实例化:

class Vehicle {
  constructor() {
    console.log(new.target);
    if (new.target === Vehicle) {
      throw new Error('Vehicle cannot be directly instantiated');
    }
  }
}

// 派生类
class Bus extends Vehicle {}
new Bus();       // class Bus {}
new Vehicle();   // class Vehicle {}
// Error: Vehicle cannot be directly instantiated

通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法

class Vehicle {
  constructor() {
    console.log(new.target);
    if (new.target === Vehicle) {
      throw new Error('Vehicle cannot be directly instantiated');
    }
    if (!this.foo) {
      throw new Error('Inheriting class must define foo()');
    }
    console.log('success!');
  }
}

// 派生类
class Bus extends Vehicle {
  foo() {}
}
// 派生类
class Van extends Vehicle {}

new Bus(); // success!
new Van(); // Error: Inheriting class must define foo()

把不同类的行为集中到一个类是一种常见的 JavaScript 模式,一个实现思路是在 extends 关键字后面提供一个 JS 表达式:

class Vehicle {}

function getParentClass() {
  console.log('evaluated expression');
  return Vehicle;
}

class Bus extends getParentClass() {}

通过表达式就可以实现多个类的混合,例如 Person 类需要组合 A、B、C 类,则需要实现 B 继承 A,C 再继承 B,而 Person 继承 C 即可

class Vehicle {}

let FooMixin = (Superclass) => class extends Superclass {
  foo() {
    console.log('foo');
  }
};

let BarMixin = (Superclass) => class extends Superclass {
  bar() {
    console.log('bar');
  } 
};

let BazMixin = (Superclass) => class extends Superclass {
  baz() {
    console.log('baz');
  }
};

// 辅助函数
function mix(BaseClass, ...Mixins) {
  return Mixins.reduce((accumulator, current) => current(accumulator), BaseClass);
}

class Bus extends mix(Vehicle, FooMixin, BarMixin, BazMixin) {}

let b = new Bus();
b.foo();  // foo
b.bar();  // bar
b.baz();  // baz

一个众所周知的设计原则是“组合优于继承”,因此混合模式逐渐被抛弃,转向了组合模式,因此这里作为了解即可,将来能够看懂代码作者的设计用意。